轻松聘
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

755 lines
19 KiB

import { Ionicons } from '@expo/vector-icons';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { useCallback, useMemo, useRef, useState } from 'react';
import {
ActivityIndicator,
Alert,
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useConversation } from '../../src/hooks';
import { colors } from '../../src/theme';
import type { ChatCard, ChatMessage } from '../../src/types';
/* ===================== 子组件:顶部导航 ===================== */
function Header({
contactName,
companyName,
positionName,
avatar,
online,
onBack,
onMore,
}: {
contactName: string;
companyName: string;
positionName: string;
avatar: string;
online: boolean;
onBack: () => void;
onMore: () => void;
}) {
return (
<View style={styles.header}>
<Pressable style={styles.headerBtn} onPress={onBack} hitSlop={8}>
<Ionicons name="chevron-back" size={24} color={colors.textPrimary} />
</Pressable>
<View style={styles.headerCenter}>
<View style={[styles.avatar, { backgroundColor: colors.primary }]}>
<Text style={styles.avatarText}>{avatar}</Text>
</View>
<View style={styles.headerInfo}>
<View style={styles.headerNameRow}>
<Text style={styles.headerName}>{contactName}</Text>
{online && <View style={styles.onlineDot} />}
</View>
<Text style={styles.headerSub}>
{companyName} · {positionName}
</Text>
</View>
</View>
<Pressable style={styles.headerBtn} onPress={onMore} hitSlop={8}>
<Ionicons name="ellipsis-horizontal" size={22} color={colors.textPrimary} />
</Pressable>
</View>
);
}
/* ===================== 子组件:双向匹配横幅 ===================== */
function MatchBanner({ score, contactName }: { score: number; contactName: string }) {
return (
<View style={styles.banner}>
<View style={styles.bannerIconWrap}>
<Ionicons name="heart" size={18} color={colors.success} />
</View>
<View style={styles.bannerBody}>
<Text style={styles.bannerTitle}>
恭喜!您与{contactName}双向匹配成功
</Text>
<Text style={styles.bannerMeta}>
匹配度 {score}% · 立即沟通
</Text>
</View>
<Ionicons name="chevron-forward" size={18} color={colors.successText} />
</View>
);
}
/* ===================== 子组件:消息气泡 ===================== */
function Avatar({ text }: { text: string }) {
return (
<View style={[styles.avatar, { backgroundColor: colors.primary }]}>
<Text style={styles.avatarText}>{text}</Text>
</View>
);
}
function TextBubble({
message,
isUser,
}: {
message: ChatMessage;
isUser: boolean;
}) {
return (
<View
style={[
styles.bubble,
isUser ? styles.bubbleUser : styles.bubbleHr,
]}
>
<Text
style={[
styles.bubbleText,
isUser ? styles.bubbleTextUser : styles.bubbleTextHr,
]}
>
{message.content}
</Text>
</View>
);
}
function SystemMessage({ content }: { content: string }) {
return (
<View style={styles.systemWrap}>
<Text style={styles.systemText}>{content}</Text>
</View>
);
}
/* ===================== 子组件:富消息卡片 ===================== */
function InterviewCard({ card }: { card: ChatCard }) {
return (
<View style={styles.cardBase}>
<View style={styles.cardHeader}>
<Ionicons name="clipboard-outline" size={16} color={colors.success} />
<Text style={styles.cardTitle}>{card.title}</Text>
</View>
<View style={styles.cardList}>
{card.items?.map((item, idx) => (
<View key={idx} style={styles.cardListRow}>
<View style={styles.cardBullet}>
<Text style={styles.cardBulletText}>{idx + 1}</Text>
</View>
<Text style={styles.cardItemText}>{item}</Text>
</View>
))}
</View>
<Pressable style={styles.cardAction} onPress={() => Alert.alert('提示', '功能开发中,敬请期待')}>
<Text style={styles.cardActionText}>{card.actionText}</Text>
<Ionicons name="chevron-forward" size={14} color={colors.primary} />
</Pressable>
</View>
);
}
function SalaryCard({ card }: { card: ChatCard }) {
return (
<View style={[styles.cardBase, styles.cardSalary]}>
<View style={styles.cardHeader}>
<Ionicons name="cash-outline" size={16} color={colors.info} />
<Text style={styles.cardTitle}>{card.title}</Text>
</View>
{card.subtitle && (
<Text style={styles.cardSubtitle}>{card.subtitle}</Text>
)}
<Pressable style={styles.cardAction} onPress={() => Alert.alert('提示', '功能开发中,敬请期待')}>
<Text style={styles.cardActionText}>{card.actionText}</Text>
<Ionicons name="chevron-forward" size={14} color={colors.primary} />
</Pressable>
</View>
);
}
function OfferCard({ card }: { card: ChatCard }) {
const router = useRouter();
return (
<View style={[styles.cardBase, styles.cardOffer]}>
<View style={styles.cardOfferHeader}>
<Ionicons name="gift" size={20} color={colors.action} />
<View style={styles.cardOfferTitleWrap}>
<Text style={styles.cardOfferTitle}>{card.title}</Text>
{card.subtitle && <Text style={styles.cardOfferSubtitle}>{card.subtitle}</Text>}
</View>
</View>
<Pressable style={styles.cardOfferBtn} onPress={() => router.push('/jobs/offer-compare')}>
<Text style={styles.cardOfferBtnText}>{card.actionText}</Text>
<Ionicons name="chevron-forward" size={14} color={colors.actionText} />
</Pressable>
</View>
);
}
function MessageCard({ card }: { card: ChatCard }) {
switch (card.type) {
case 'interview':
return <InterviewCard card={card} />;
case 'salary':
return <SalaryCard card={card} />;
case 'offer':
return <OfferCard card={card} />;
default:
return null;
}
}
/* ===================== 子组件:单条消息行 ===================== */
function MessageRow({
message,
avatar,
}: {
message: ChatMessage;
avatar: string;
}) {
const isUser = message.role === 'user';
const isSystem = message.role === 'system';
if (isSystem) {
return <SystemMessage content={message.content ?? ''} />;
}
return (
<View
style={[
styles.messageRow,
isUser ? styles.messageRowRight : styles.messageRowLeft,
]}
>
{!isUser && <Avatar text={avatar} />}
<View style={styles.messageBody}>
{message.content ? (
<TextBubble message={message} isUser={isUser} />
) : null}
{message.card ? <MessageCard card={message.card} /> : null}
</View>
</View>
);
}
/* ===================== 子组件:时间戳 ===================== */
function Timestamp({ time }: { time: string }) {
return (
<View style={styles.timeWrap}>
<Text style={styles.timeText}>{time}</Text>
</View>
);
}
/* ===================== 子组件:底部输入框 ===================== */
function InputBar({
value,
onChange,
onSend,
sending,
}: {
value: string;
onChange: (text: string) => void;
onSend: () => void;
sending: boolean;
}) {
return (
<View style={styles.inputBar}>
<View style={styles.inputLeftActions}>
<Pressable style={styles.inputIconBtn} onPress={() => Alert.alert('提示', '功能开发中,敬请期待')}>
<Ionicons name="happy-outline" size={24} color={colors.textSecondary} />
</Pressable>
<Pressable style={styles.inputIconBtn} onPress={() => Alert.alert('提示', '功能开发中,敬请期待')}>
<Ionicons name="image-outline" size={24} color={colors.textSecondary} />
</Pressable>
<Pressable style={styles.inputIconBtn} onPress={() => Alert.alert('提示', '功能开发中,敬请期待')}>
<Ionicons name="folder-outline" size={24} color={colors.textSecondary} />
</Pressable>
</View>
<View style={styles.inputWrap}>
<TextInput
style={styles.input}
placeholder="输入消息…"
placeholderTextColor={colors.textHint}
value={value}
onChangeText={onChange}
multiline
maxLength={500}
editable={!sending}
/>
<Pressable style={styles.plusBtn} onPress={() => Alert.alert('提示', '功能开发中,敬请期待')}>
<Ionicons name="add" size={22} color={colors.textSecondary} />
</Pressable>
</View>
<Pressable
style={[styles.sendBtn, (!value.trim() || sending) && styles.sendBtnDisabled]}
onPress={onSend}
disabled={!value.trim() || sending}
>
<Ionicons name="send" size={18} color={colors.white} />
</Pressable>
</View>
);
}
/* ===================== 页面 ===================== */
export default function ChatDetailScreen() {
const router = useRouter();
const { id } = useLocalSearchParams<{ id: string }>();
const { detail, loading, error, refresh, sendMessage, sending } = useConversation(id ?? '');
const [input, setInput] = useState('');
const scrollRef = useRef<ScrollView>(null);
const messages = useMemo(() => detail?.messages ?? [], [detail]);
const handleSend = useCallback(async () => {
const text = input.trim();
if (!text || sending) return;
setInput('');
await sendMessage(text);
// 滚动到底部
setTimeout(() => scrollRef.current?.scrollToEnd({ animated: true }), 100);
}, [input, sending, sendMessage]);
// 右上角「更多」暂无对应菜单页,按本文件既有约定弹提示(此前为空函数,点了无反应)
const handleMore = () => {
Alert.alert('提示', '功能开发中,敬请期待');
};
if (loading) {
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<View style={styles.stateBox}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
</SafeAreaView>
);
}
if (error || !detail) {
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<View style={styles.stateBox}>
<Text style={styles.stateText}>{error ? '加载失败' : '会话不存在'}</Text>
<Pressable style={styles.retryBtn} onPress={refresh}>
<Text style={styles.retryText}>重试</Text>
</Pressable>
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<Header
contactName={detail.contactName}
companyName={detail.companyName}
positionName={detail.positionName}
avatar={detail.avatar}
online={detail.online}
onBack={() => router.back()}
onMore={handleMore}
/>
{detail.matchInfo && (
<MatchBanner score={detail.matchInfo.score} contactName={detail.contactName} />
)}
<KeyboardAvoidingView
style={styles.flex}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
keyboardVerticalOffset={Platform.OS === 'ios' ? 100 : 0}
>
<ScrollView
ref={scrollRef}
style={styles.scroll}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
onContentSizeChange={() => scrollRef.current?.scrollToEnd({ animated: false })}
>
{messages.map((message, index) => {
const showTime =
index === 0 ||
messages[index - 1].timestamp !== message.timestamp ||
message.role !== messages[index - 1].role;
return (
<View key={message.id}>
{showTime && message.timestamp ? (
<Timestamp time={message.timestamp} />
) : null}
<MessageRow message={message} avatar={detail.avatar} />
</View>
);
})}
</ScrollView>
<InputBar
value={input}
onChange={setInput}
onSend={handleSend}
sending={sending}
/>
</KeyboardAvoidingView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: colors.pageBg,
},
flex: {
flex: 1,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 8,
height: 52,
backgroundColor: colors.white,
borderBottomWidth: 1,
borderBottomColor: colors.borderLight,
},
headerBtn: {
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
},
headerCenter: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
justifyContent: 'center',
},
headerInfo: {
marginLeft: 10,
alignItems: 'center',
},
headerNameRow: {
flexDirection: 'row',
alignItems: 'center',
},
headerName: {
fontSize: 16,
fontWeight: '700',
color: colors.textPrimary,
},
onlineDot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: colors.success,
marginLeft: 6,
},
headerSub: {
marginTop: 2,
fontSize: 12,
color: colors.textSecondary,
},
avatar: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
},
avatarText: {
fontSize: 14,
fontWeight: '700',
color: colors.white,
},
banner: {
flexDirection: 'row',
alignItems: 'center',
marginHorizontal: 8,
marginTop: 8,
padding: 10,
borderRadius: 8,
backgroundColor: colors.successLight,
},
bannerIconWrap: {
width: 34,
height: 34,
borderRadius: 17,
backgroundColor: colors.white,
alignItems: 'center',
justifyContent: 'center',
marginRight: 10,
},
bannerBody: {
flex: 1,
},
bannerTitle: {
fontSize: 14,
fontWeight: '700',
color: colors.successText,
},
bannerMeta: {
marginTop: 2,
fontSize: 12,
color: colors.success,
},
scroll: {
flex: 1,
},
scrollContent: {
padding: 8,
paddingBottom: 8,
},
systemWrap: {
alignItems: 'center',
marginVertical: 10,
},
systemText: {
fontSize: 12,
color: colors.textHint,
},
timeWrap: {
alignItems: 'center',
marginVertical: 6,
},
timeText: {
fontSize: 11,
color: colors.textHint,
},
messageRow: {
flexDirection: 'row',
marginBottom: 10,
maxWidth: '82%',
},
messageRowLeft: {
alignSelf: 'flex-start',
},
messageRowRight: {
alignSelf: 'flex-end',
flexDirection: 'row-reverse',
},
messageBody: {
marginLeft: 8,
marginRight: 8,
},
bubble: {
paddingHorizontal: 12,
paddingVertical: 9,
borderRadius: 12,
maxWidth: '100%',
},
bubbleHr: {
backgroundColor: colors.white,
borderBottomLeftRadius: 4,
},
bubbleUser: {
backgroundColor: colors.primary,
borderBottomRightRadius: 4,
},
bubbleText: {
fontSize: 14,
lineHeight: 20,
},
bubbleTextHr: {
color: colors.textPrimary,
},
bubbleTextUser: {
color: colors.white,
},
cardBase: {
width: 260,
backgroundColor: colors.white,
borderRadius: 10,
padding: 12,
marginTop: 6,
},
cardHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 8,
},
cardTitle: {
marginLeft: 6,
fontSize: 14,
fontWeight: '700',
color: colors.textPrimary,
},
cardSalary: {
backgroundColor: colors.primaryLight,
},
cardList: {
marginBottom: 8,
},
cardListRow: {
flexDirection: 'row',
alignItems: 'flex-start',
marginBottom: 6,
},
cardBullet: {
width: 16,
height: 16,
borderRadius: 8,
backgroundColor: colors.successLight,
alignItems: 'center',
justifyContent: 'center',
marginRight: 6,
marginTop: 2,
},
cardBulletText: {
fontSize: 10,
fontWeight: '700',
color: colors.success,
},
cardItemText: {
flex: 1,
fontSize: 13,
color: colors.textPrimary,
},
cardSubtitle: {
fontSize: 12,
lineHeight: 18,
color: colors.textSecondary,
marginBottom: 10,
},
cardAction: {
flexDirection: 'row',
alignItems: 'center',
paddingTop: 8,
borderTopWidth: 1,
borderTopColor: colors.borderLight,
},
cardActionText: {
flex: 1,
fontSize: 12,
color: colors.primary,
fontWeight: '600',
},
cardOffer: {
width: 260,
backgroundColor: colors.actionLight,
borderRadius: 10,
padding: 12,
marginTop: 6,
},
cardOfferHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 10,
},
cardOfferTitleWrap: {
flex: 1,
marginLeft: 10,
},
cardOfferTitle: {
fontSize: 14,
fontWeight: '700',
color: colors.actionText,
},
cardOfferSubtitle: {
marginTop: 2,
fontSize: 12,
color: colors.textSecondary,
},
cardOfferBtn: {
flexDirection: 'row',
alignItems: 'center',
paddingTop: 8,
borderTopWidth: 1,
borderTopColor: colors.actionBorder,
},
cardOfferBtnText: {
flex: 1,
fontSize: 12,
color: colors.actionText,
fontWeight: '600',
},
inputBar: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 6,
paddingVertical: 8,
backgroundColor: colors.white,
borderTopWidth: 1,
borderTopColor: colors.borderLight,
},
inputLeftActions: {
flexDirection: 'row',
alignItems: 'center',
},
inputIconBtn: {
width: 34,
height: 34,
alignItems: 'center',
justifyContent: 'center',
},
inputWrap: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
marginHorizontal: 4,
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 18,
backgroundColor: colors.pageBg,
},
input: {
flex: 1,
maxHeight: 80,
minHeight: 34,
fontSize: 14,
color: colors.textPrimary,
paddingRight: 8,
},
plusBtn: {
width: 28,
height: 28,
alignItems: 'center',
justifyContent: 'center',
},
sendBtn: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.primaryBtn,
},
sendBtnDisabled: {
opacity: 0.5,
},
stateBox: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
stateText: {
marginTop: 12,
fontSize: 14,
color: colors.textSecondary,
},
retryBtn: {
marginTop: 16,
paddingHorizontal: 24,
paddingVertical: 8,
borderRadius: 18,
backgroundColor: colors.primaryBtn,
},
retryText: {
fontSize: 13,
fontWeight: '600',
color: colors.white,
},
});